Eloquent 是 Active Record 模式:一個 Model 類別同時扮演「資料」跟「怎麼存取這筆資料」兩個角色,物件自己知道怎麼把自己存進資料庫:
$link = new Link();
$link->code = 'abc123';
$link->original_url = 'https://example.com';
$link->save(); // Model 自己知道怎麼存自己
$link = Link::where('code', 'abc123')->first(); // 查詢邏輯也寫在 Model 上
Spring Data JPA 是 Data Mapper 模式:Entity 純粹只是資料容器,完全不知道怎麼把自己存進資料庫;存取邏輯獨立在另一個 Repository 物件上:
Link link = new Link();
link.setCode("abc123");
link.setOriginalUrl("https://example.com");
linkRepository.save(link); // 存的動作由 Repository 觸發,不是 Link 自己
Optional<Link> found = linkRepository.findByCode("abc123"); // 查詢邏輯也在 Repository
這不是 Java 比較囉唆而已,是兩邊的架構哲學根本不同:Active Record 讓 Model 兩用、寫起來快,但也讓 Model 承擔太多職責(資料 + 商業邏輯 + 查詢邏輯全部黏在一起,Model 一大就難維護);Data Mapper 把「資料長什麼樣」跟「怎麼存取」拆開,每個 Entity 只單純是資料,查詢/存取的責任交給 Repository,換取的是清楚的職責邊界。這個轉換想通了,後面看到「Entity 上為什麼不能寫查詢方法」這類問題就不會覺得奇怪。
Link Entity欄位對齊之前 docs/SPEC.md 定的資料模型:
@Entity
@Table(name = "links")
public class Link {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String code;
@Column(name = "original_url", nullable = false)
private String originalUrl;
@Column(name = "owner_id", nullable = false)
private Long ownerId;
private Integer clickCount = 0;
private LocalDateTime expiresAt;
private LocalDateTime createdAt;
// getter/setter 省略,之後可以考慮用 Lombok 的 @Getter/@Setter 減少樣板
}
對照 Eloquent:
class Link extends Model
{
protected $fillable = ['code', 'original_url', 'owner_id', 'expires_at'];
protected $casts = [
'expires_at' => 'datetime',
];
}
Laravel 用 $fillable 宣告「哪些欄位可以被批量賦值」、$casts 宣告型別轉換;JPA 沒有這兩個概念的直接對應——型別轉換這件事在 JPA 裡是靜態的,欄位宣告成 LocalDateTime 型別,JPA 就知道要怎麼跟資料庫的日期時間型別互轉,不需要額外宣告一份 casts 清單。
要注意:這篇只先定義 Entity 本身的欄位對應,資料表要怎麼被建出來(CREATE TABLE 這件事)留到 Day08(Flyway/Liquibase vs Laravel Migration)講——這篇先讓 Entity 跟 Repository 能動起來,本機開發階段先讓 Hibernate 自動建表就好。
Spring Data JPA 最反直覺、也最方便的地方:你只要宣告一個介面,不用寫任何實作,框架會在啟動時自動生成:
public interface LinkRepository extends JpaRepository<Link, Long> {
Optional<Link> findByCode(String code);
boolean existsByCode(String code);
}
findByCode、existsByCode 這些方法完全沒有實作內容,Spring Data JPA 會照方法名稱去解析出對應的 SQL——findByCode 就是 WHERE code = ?,這是靠嚴格的命名規則做到的,方法名稱打錯字或规则不符,程式碼編譯得過,但啟動時會直接噴錯(這點跟 Eloquent 動態方法比起來,算是「錯得早」的例子,呼應 Day02 講過的靜態型別 vs 動態型別的回饋時機差異)。
繼承 JpaRepository<Link, Long> 就已經免費拿到一整組基本 CRUD 方法(save、findById、findAll、deleteById⋯),對照 Eloquent 內建的 Model::find()、Model::all() 這些靜態方法,效果類似,只是拿到的地方不同——Eloquent 是 Model 類別本身內建,Spring Data JPA 是繼承 JpaRepository 介面免費拿到。
Day05 寫的 store() 方法裡有兩個 TODO,現在可以填上了:
@Service
class LinkService {
private final LinkRepository linkRepository;
LinkService(LinkRepository linkRepository) {
this.linkRepository = linkRepository;
}
LinkResponse create(CreateLinkRequest request) {
Link link = new Link();
link.setCode("abc123"); // Day14 才會講怎麼真正產生短碼,這裡先沿用假值
link.setOriginalUrl(request.url());
link.setOwnerId(1L); // 30 天內固定是單一使用者,見 architecture-notes.md
link.setCreatedAt(LocalDateTime.now());
Link saved = linkRepository.save(link);
return new LinkResponse(saved.getCode(), "https://short.ly/" + saved.getCode());
}
Optional<LinkResponse> findByCode(String code) {
return linkRepository.findByCode(code)
.map(link -> new LinkResponse(link.getCode(), "https://short.ly/" + link.getCode()));
}
}
回傳型別用 Optional<LinkResponse>——這就是 Day02 講過的 Optional,用型別明確表達「這個短碼可能查不到」,呼叫端(Controller)要用 .map()/.orElse() 處理,而不是回傳一個可能是 null 的物件讓呼叫端自己記得檢查。Controller 怎麼接這個 Service(分層架構的完整樣貌),會是 Day09 的主題,這篇先把資料層打通就好。